Skip to content

feat(table): per-block/table version-range metadata for scan skipping#2304

Open
shaunpatterson wants to merge 4 commits into
dgraph-io:mainfrom
shaunpatterson:feat/version-range-skip
Open

feat(table): per-block/table version-range metadata for scan skipping#2304
shaunpatterson wants to merge 4 commits into
dgraph-io:mainfrom
shaunpatterson:feat/version-range-skip

Conversation

@shaunpatterson

Copy link
Copy Markdown
Contributor

Motivation

Today a table carries only a per-table max_version and a table-level lower bound via SinceTs. There is no per-block version metadata and no upper bound, so a version-bounded scan (e.g. SinceTs-based incremental backup, or reading only versions at/below a known timestamp) must decompress and walk version sprawl it will then discard.

What changed

  • Block-property metadata. BlockOffset gains min_version/max_version (and the near-free uncompressed_len/key_count); TableIndex gains min_version. The builder tracks these over every key (deletes included — their ts is in the key).
  • New IteratorOptions.UntilTs — an inclusive upper version bound, complementing SinceTs to define a window (SinceTs, UntilTs].
  • A whole-table / whole-block skip when the table's or block's [minVersion, maxVersion] does not intersect the active window.

The flatbuffer fields are appended (forward/backward compatible): old readers ignore them, and new readers treat a table written without them as "range unknown" and never skip it.

Compatibility & correctness

The skip is a conservative over-approximation and a pure prune — the per-entry version filter remains authoritative. A block/table is skipped only when it provably contains no version in the window. The window lower bound is aligned exactly with the per-entry filter's keep boundary (lower = 0 when SinceTs == 0, else SinceTs+1), so an UntilTs-only scan cannot wrongly skip a low-version entry; SinceTs == MaxUint64 is guarded against overflow. Default behavior (no version bounds set) is unchanged.

Testing

go build, go vet, full go test . -count=1 green. Tests cover: bounded-scan results identical with vs. without the skip across multiple windows incl. deletes; old-format tables never skipped; blocks/tables provably outside the window actually pruned; the version-window lower-bound boundary cases.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NtGkC4K2J2XYwcAKwjhHbM

shaunpatterson and others added 2 commits June 22, 2026 14:21
Add an inclusive [minVersion,maxVersion] version range to every BlockOffset
and a min_version to TableIndex (max_version already existed), appended to the
FlatBuffers schema. Appending keeps the format forward/backward compatible:
old readers ignore the new fields and tables written by older code decode them
as absent. Also append the near-free uncompressed_len + key_count to
BlockOffset in the same schema bump.

flatc was unavailable, so fb/TableIndex.go and fb/BlockOffset.go were
hand-edited following the project's prior prefix-bloom precedent: new readers
gate on rcv._tab.Offset(vtableOffset) != 0 (vtableOffset = 4 + 2*slot),
StartObject counts bumped (TableIndex 7->8, BlockOffset 3->7), and matching
Add*/Mutate* builder helpers added. Presence (not value) gates skipping, so a
block whose true min/max are both 0 is conservatively treated as unknown and
never pruned.

The builder tracks a conservative per-block and per-table version range over
every key's parsed ts, including tombstones (their version is in the key), so
the range is always an over-approximation. A new IteratorOptions.UntilTs gives
an inclusive upper bound complementing SinceTs, defining the window
(SinceTs, UntilTs]. At table-pick time a table whose range falls entirely above
UntilTs is skipped; the table Iterator skips whole blocks outside the window
without decompressing them. Tables/blocks lacking range metadata are never
skipped. The skip is a pure performance prune; the per-entry version filter
(now also honoring UntilTs) stays authoritative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGkC4K2J2XYwcAKwjhHbM
versionWindow() computed lower=SinceTs+1 unconditionally, but the authoritative
per-entry filter only excludes v<=SinceTs when SinceTs>0. On an UntilTs-only scan
(SinceTs==0), version-0 entries are in-window, yet the storage prune used lower=1
and would drop any block/table whose maxVersion==0 (0 < 1), silently omitting
version-0 data.

Set lower=0 when SinceTs==0 (the smallest version the per-entry filter keeps),
else SinceTs+1. Also guard SinceTs==MaxUint64: SinceTs+1 wraps to 0, so return an
empty window (lower=MaxUint64, upper=0) which prunes every block, matching the
per-entry filter that keeps nothing in that case. Route the table-level
pickTable/pickTables prune through versionWindow so both layers use identical
bounds.

Regression tests: TestVersionWindowLowerBound (boundary table incl. overflow),
TestUntilTsKeepsVersionZeroTable (table picker keeps a [0,0] table on an
UntilTs-only scan), table/TestVersionZeroBlockNotSkipped (version-0 block not
skipped on a lower=0 window), and TestUntilTsLowerBoundKeepsSmallestVersion
(end-to-end: badger rejects CommitTs==0, so the smallest storable version 1
survives an UntilTs-only scan after flush).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGkC4K2J2XYwcAKwjhHbM
@shaunpatterson shaunpatterson requested a review from a team as a code owner June 22, 2026 18:24
shaunpatterson and others added 2 commits July 10, 2026 20:53
The reverse non-AllVersions walk-up loop advanced to a newer version whenever
nextTs <= readTs, ignoring the UntilTs upper bound. With versions 3,4,7 and
UntilTs=4, a reverse scan climbed past the window and returned v7, while the
forward path correctly returns v4 (the per-entry filter drops v7). So a reverse
scan disagreed with forward, and could return a version outside the requested
(SinceTs, UntilTs] window. Gate the walk-up on UntilTs as well. SinceTs needs no
re-check: candidates climb in increasing ts, so any version newer than the
in-window entry is also > SinceTs.

Adds TestUntilTsReverseReturnsInWindowVersion (reverse+forward parity); the
existing window test uses AllVersions, which returns before this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Block-level version skipping (SetVersionBounds) can leave a table iterator invalid
after Rewind when every block falls outside the window (seekToFirst/seekToLast set
io.EOF). ConcatIterator.Rewind only positioned on the first table without looping,
so if that table was fully pruned the ConcatIterator — and its MergeIterator level —
looked exhausted and stranded every later table, silently dropping in-window entries
(reachable at L1+ where a level uses a ConcatIterator; e.g. a table with old keys in
early blocks and recent keys in later blocks, scanned with a mid version window).

Add skipInvalidTables(), called from Rewind, mirroring the empty-table loop already
in Next(). No-op when version bounds are disabled. Seek is unaffected: seekFrom does
not version-skip blocks and the binary search guarantees the chosen table holds a key
>= the seek key. Adds TestConcatIteratorSkipsFullyPrunedFirstTable (fails without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant